package com.nulldev.util.net.tcp.client;

import java.io.IOException;
import java.net.Socket;
import java.net.SocketException;
import java.security.PrivateKey;
import java.util.regex.Pattern;

import com.nulldev.util.crypto.encryptAPI.rsa.RSA;
import com.nulldev.util.encoding.compression.smartCompression;
import com.nulldev.util.java.DataSet;
import com.nulldev.util.java.JVMUtil;
import com.nulldev.util.java.Variables;
import com.nulldev.util.lang.json.JsonObject;
import com.nulldev.util.logging.Logger;
import com.nulldev.util.logging.LoggerFactory;
import com.nulldev.util.napi.nullAPI;
import com.nulldev.util.net.dsap.Client;
import com.nulldev.util.net.packets.ConnectionManager;
import com.nulldev.util.net.packets.NetInfo;
import com.nulldev.util.net.packets.NetPacket;
import com.nulldev.util.net.packets.PacketManager;
import com.nulldev.util.net.packets.PacketMode;
import com.nulldev.util.net.tcp.TCPCore;
import com.nulldev.util.net.tcp.fRetry;
import com.nulldev.util.other.ByteUtils;

public abstract class TCPClient extends fRetry implements Client {
	public boolean PREFER_THREAD_API;
	
	private String ipP = "unknown";
	private String ID_KEY = "unknown";
	public final Logger logger = LoggerFactory.getLoggerD(
			JVMUtil.isArg("--network.logging"), "TCPClient", true, false);
	private boolean connected;
	private String host;
	private int port;

	private volatile ClientConnection connection;
	private int reconnectDelay = 10 * 1000;
	private final Object waitForConnection = new Object();
	final Object waitForClose = new Object();
	
	public TCPClient (String category, String name, String host, int port) {
		super(category, name);
		this.host = host;
		this.port = port;
	}
	
	public TCPClient (String category, String name) {
		super(category, name);
		this.host = "";
		this.port = 0;
	}
	
	public void updateHost(final String host, final int port) {
		this.host = host;
		this.port = port;
	}
	
	public boolean isConnected() {
		return this.connected;
	}
	
	public Logger getLogger() {
		return this.logger;
	}

	private long tries = 0;
	private Socket socket;
	protected void retry () {
		socket = null;
		if (host == "" && port == 0) {
			return;
		}
		try {
			socket = new Socket(host, port);
		} catch (IOException ex) {
			if (logger.isErrorEnabled()) logger.error(category, "Unable to connect: " + host + ":" + port);
			failed();
			return;
		}
		success();
		try {
			socket.setKeepAlive(true);
			//socket.setReceiveBufferSize(100000000);
			//socket.setSendBufferSize(100000000);
		} catch (SocketException e) {
			e.printStackTrace();
		}
		if (logger.traceEnabled) logger.trace(category, "Connected: " + socket.getInetAddress() + ":" + socket.getPort());

		try {
			connection = new ClientConnection(category, name, socket);
			connection.start();
			this.connected = true;
		} catch (IOException ex) {
			if (logger.isErrorEnabled()) logger.error(category, "Error configuring client connection.", ex);
			failed();
			return;
		}

		connected(connection);
		synchronized (waitForConnection) {
			waitForConnection.notifyAll();
		}
		waitForClose(0);
		disconnected(connection);
	}
	
	public void setKey(final String key) {
		this.ID_KEY = key;
	}
	
	public String getKey() {
		return this.ID_KEY;
	}

	protected void stopped () {
		ClientConnection connection = this.connection;
		if (connection != null) connection.close();
	}
	
	public boolean send(final NetPacket packet) {
		return send(packet.toString());
	}

	public boolean send (String message) {
		ClientConnection connection = this.connection;
		if (connection == null) {
			if (logger.debugEnabled) logger.debug(category, "Unable to send, connection is closed: " + message);
			return false;
		}
		connection.send(message);
		return true;
	}
	
	public boolean sendBlockingV3(final NetPacket packet) {
		return sendBlocking(packet.toString(PacketMode.STANDARD_V2, null));
	}
	
	public boolean sendBlocking(final NetPacket packet) {
		return this.sendBlocking(packet, PacketMode.defaultMode);
	}
	
	public boolean sendBlocking(final NetPacket packet, final PacketMode mode) {
		return this.sendBlocking(packet, mode, null);
	}
	
	public boolean sendBlocking(final NetPacket packet, final NetInfo info) {
		return this.sendBlocking(packet, PacketMode.defaultMode, info);
	}
	
	public boolean sendBlocking(final NetPacket packet, final PacketMode mode, final NetInfo netInfo) {
		final String text = packet.toString(mode, null);
		if (text == null && mode == PacketMode.STANDARD_V2_FULLY_ENCRYPTED) {
			return sendBlocking(packet.getHeader(mode, netInfo), packet.autoReadData(mode, netInfo));
		}else if (text == null && mode == PacketMode.STANDARD_V2_COMPRESSED) {
			final byte[] d = packet.autoReadData(mode, netInfo);
			//JVMUtil.async_error("Compressed using " + smartCompression.compressMethodFromID(smartCompression.getMethod(d)) + ".");
			return sendBlocking(packet.getHeader(mode, netInfo), ByteUtils.toBase64(d).getBytes());
		}
		if (text.getBytes().length >= 62000 || packet.getData().length >= 62000 || packet.preferBytePayload()) {
			return sendBlocking(packet.getHeader(mode, netInfo), packet.autoReadData(mode, netInfo));
		}
		return sendBlocking(text);
	}

	public boolean sendBlocking (String message) {
		message = message.replaceAll(Pattern.quote(" "), "%space");
		ClientConnection connection = this.connection;
		if (connection == null) {
			if (logger.debugEnabled) logger.debug(category, "Unable to send, connection is closed: " + message);
			return false;
		}
		return connection.sendBlocking(message);
	}

	public boolean sendBlocking (String message, byte[] bytes) {
		return sendBlocking(message, bytes, 0, bytes.length);
	}

	public boolean sendBlocking (String message, byte[] bytes, int offset, int count) {
		ClientConnection connection = this.connection;
		if (connection == null) {
			if (logger.debugEnabled) logger.debug(category, "Unable to send, connection is closed: " + message);
			return false;
		}
		return connection.sendBlocking(message, bytes, offset, count);
	}

	public void connected (TCPCore connection) {
	}

	public void disconnected (TCPCore connection) {
	}

	public abstract void receive (String event, String payload, byte[] bytes, long count, final long readTime);
	
	public void onRemove(final String reason) {}

	public ClientConnection getConnection () {
		return connection;
	}
	
	public String format() {
		if (Variables.notNull(this.socket)) {
			return this.socket.getInetAddress().getHostAddress() + ":" + this.socket.getPort();
		}else {
			return this.host + ":" + this.port;
		}		
	}
	
	public int getPort() {
		return this.port;
	}
	
	public String getIP() {
		return this.socket.getInetAddress().getHostAddress();
	}
	
	public String formatS() {
		return this.socket.getInetAddress().getHostAddress() + ":" + this.socket.getPort();	
	}
	
	public String formatHS() {
		return this.host + ":" + this.port;
	}

	/** @param millis 0 to wait forever. */
	public boolean waitForConnection (long millis) {
		if (logger.traceEnabled) logger.trace(category, "Waiting for connection.");
		long until = System.currentTimeMillis() + millis;
		while (true) {
			if (System.currentTimeMillis() >= until) {
				this.updateHost("", 0);
				return false;
			}
			synchronized (waitForConnection) {
				ClientConnection connection = TCPClient.this.connection;
				if (connection != null && !connection.closed) return true;
				long wait = 0;
				if (millis > 0) {
					wait = until - System.currentTimeMillis();
					if (wait < 0) return false;
				}
				try {
					waitForConnection.wait(wait);
				} catch (InterruptedException ignored) {
				}
			}
		}
	}

	/** @param millis 0 to wait forever.
	 * @return true if the close happened. */
	public boolean waitForClose (long millis) {
		if (logger.traceEnabled) logger.trace(category, "Waiting for close.");
		long until = System.currentTimeMillis() + millis;
		while (true) {
			synchronized (waitForClose) {
				ClientConnection connection = TCPClient.this.connection;
				if (connection == null || connection.closed) return true;
				long wait = 0;
				if (millis > 0) {
					wait = until - System.currentTimeMillis();
					if (wait < 0) return false;
				}
				try {
					waitForClose.wait(wait);
				} catch (InterruptedException ignored) {
				}
			}
			if (logger.traceEnabled) logger.trace(category, "Waiting for close...");
		}
	}

	public String getDefIP() {
		return this.ipP;
	}
	
	public void setDefIP(final String ip) {
		this.ipP = ip;
	}
	
	public class ClientConnection extends TCPCore implements Client {
		private final nullAPI apiCalls = ConnectionManager.getClientConnection(this);

		private final Socket socket;
		
		public ClientConnection (String category, String name, Socket socket) throws IOException {
			super(category, name, socket);
			this.PREFER_THREAD_API = TCPClient.this.PREFER_THREAD_API;
			this.socket = socket;
		}

		public void receive (String event, String payload, byte[] bytes, long count, final long readTime) {
			TCPClient.this.receive(event, payload, bytes, count, readTime);
		}

		public boolean close () {
			super.close();
			synchronized (waitForClose) {
				waitForClose.notifyAll();
				return true;
			}
		}

		public String getIP() {
			return TCPClient.this.getIP();
		}
		
		public int getPort() {
			return TCPClient.this.getPort();
		}

		public String getKey() {
			return TCPClient.this.getKey();
		}

		public void setKey(final String data) {
			TCPClient.this.setKey(data);
		}

		public String getDefIP() {
			return TCPClient.this.ipP;
		}
		
		public void setDefIP(final String data) {
			TCPClient.this.ipP = data;
		}
		
		public <T> Object invoke(final String command, final T object) {
			return this.apiCalls.invoke(command, object);
		}
		
		public <T> Object invoke(final String command) {
			return this.apiCalls.invoke(command);
		}

		public <T> Object invoke(final String command, final Object... object) {
			return this.apiCalls.invoke(command, object);
		}
		
		public TCPClient getTCPClient() {
			return TCPClient.this;
		}

		public void callDisconnect() {
			TCPClient.this.requestDisconnect();
		}

		public void onRemoved(final String reason) {
			TCPClient.this.onRemove(reason);
		}

		private byte[] publicKey;
		public void setPublicKey(final byte[] keyData) {
			if (keyData != null) {
				this.publicKey = keyData;
			}
		}	
		
		public boolean hasPublicKey() {
			return this.publicKey != null;
		}
		
		public byte[] getPublicKey() {
			return this.publicKey;
		}

		@Override
		public void setDSAPSupport(boolean dsapSupport) {
			TCPClient.this.setDSAPSupport(dsapSupport);
		}

		@Override
		public boolean isDSAPSupported() {
			return TCPClient.this.isDSAPSupported();
		}

		@Override
		public void setDSAPESSupport(boolean encstream_support) {
			TCPClient.this.setDSAPESSupport(encstream_support);
		}
		
		@Override
		public void onDSAP(JsonObject obj) {
			TCPClient.this.onDSAP(obj);
		}

		public void setServerPublicKey(final byte[] keyData) {
			TCPClient.this.setServerPublicKey(keyData);
		}
		
		public Socket socket() {
			return this.socket;
		}

//		public Client getClient() {
//			return this.client;
//		}
//		
//		public void createClient(final String name) {
//			this.client = new Client(name, this);
//		}
//		
//		public Channel getChannel() {
//			try {
//				return this.client.getChannel();
//			}catch (Exception e) {
//				return Client.DEFAULT_CHANNEL;
//			}
//		}
//		
//		public boolean setChannel(final Channel channel) {
//			return this.client.setChannel(channel);
//		}
//		
//		public void setChannels(final Channel[] channels) {
//			TCPClient.this.setChannels(channels);
//		}
//
//		public Channel getChannelForString(String channel) {
//			return TCPClient.this.forName(channel);
//		}
//		
//		public void callDisconnect() {
//			TCPClient.this.requestDisconnect();
//		}
//
//		public String[] getChannelsString() {
//			final ArrayList<String> ch = new ArrayList<>();
//			for (final Channel s : TCPClient.this.getChannels()) {
//				ch.add(s.getChannel());
//			}
//			return ArrayUtils.arrayListToArray(ch);
//		}
//
//		public void setClients(final ClientInstance[] clients) {
//			TCPClient.this.setClients(clients);
//		}
//
//		public ClientInstance getClientI(final String name) {
//			return TCPClient.this.getClientI(name);
//		}
	}
	
	private boolean DSAP = true, DSAP_ENCRYPTION = true;
	
	@Override
	public void setDSAPSupport(final boolean dsapSupport) {
		this.DSAP = dsapSupport;
	}

	private byte[] serverPublicKey;
	public void setServerPublicKey(final byte[] keyData) {
		this.serverPublicKey = keyData;
	}
	
	public boolean hasServerPublicKey() {
		return this.serverPublicKey != null;
	}
	
	public byte[] getServerPublicKey() {
		return this.serverPublicKey;
	}

	@Override
	public boolean isDSAPSupported() {
		return this.DSAP;
	}

	@Override
	public void setDSAPESSupport(final boolean encstream_support) {
		this.DSAP_ENCRYPTION = encstream_support;
	}

	public void requestDisconnect() {
		this.updateHost("", 0);
		this.connection.requestDisconnect();
		this.connected = false;
	}

	public boolean isKeySet() {
		return !this.ID_KEY.equals("unknown");
	}

	private byte[] privateKey, publicKey;
	
	public boolean hasPrivateKey() {
		return this.privateKey != null;
	}
	
	public boolean hasPublicKey() {
		return this.publicKey != null;
	}
	
	public void setPrivateKey(final byte[] key) {
		if (key == null) {
			return;
		}
		this.privateKey = key;
	}
	
	public void setPublicKey(final byte[] key) {
		if (key == null) {
			return;
		}
		this.publicKey = key;
	}
	
	public byte[] getPrivateKey() {
		return this.privateKey;
	}
	
	public byte[] getPublicKey() {
		return this.publicKey;
	}
	
	/**
	 * Decrypts data from NPCrypt
	 * @param enc_ncrypt
	 * @returns String
	 */
	public String decrypt(final String enc_ncrypt) {
		if (!hasPrivateKey()) {
			return enc_ncrypt;
		}
		try {
			final PrivateKey pk = RSA.getPrivateKey(this.privateKey);
			return new String(RSA.decrypt(pk, ByteUtils.fromBase64(enc_ncrypt)));
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
	}
	
	public byte[] decrypt(final byte[] enc_ncrypt) {
		if (!hasPrivateKey()) {
			return enc_ncrypt;
		}
		try {
			final PrivateKey pk = RSA.getPrivateKey(this.privateKey);
			return RSA.decrypt(pk, enc_ncrypt);
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
	}
	
	/**
	 * Creates a RSA key pair and sends the public key to the server. <br>
	 * Provided by nCrypt. <br>
	 * Required for NP3 Encryption on packets and Full NP3E packets. <br>
	 * Added in 2.6;
	 * @returns boolean [true = successful]
	 */
	public boolean createKeyPair() {
		if (!this.isConnected()) {
			return false;
		}
		final DataSet<byte[], byte[]> keys = RSA.generateKey();
		this.sendBlockingV3(PacketManager.fromID("npcrypt.public", ByteUtils.toBase64(keys.getA())));
		this.setPrivateKey(keys.getB());
		this.setPublicKey(keys.getA());
		return true;
	}
	
	/**
	 * @see createKeyPair()
	 * <br> Creates a RSA key pair but in 4096 bit mode.
	 * @returns boolean [true == successful]
	 */
	public boolean createKeyPair4096() {
		if (!this.isConnected()) {
			return false;
		}
		final DataSet<byte[], byte[]> keys = RSA.generateKey(4096);
		this.sendBlockingV3(PacketManager.fromID("npcrypt.public", ByteUtils.toBase64(keys.getA())));
		this.setPrivateKey(keys.getB());
		this.setPublicKey(keys.getA());
		return true;
	}
	
	public String createString(final byte[] data) {
		return new String(data);
	}
	
	@Override
	public void onDSAP(JsonObject obj) {	
	}

	public Socket socket() {
		return this.socket;
	}
}
